feat: use clientLoader + localStorage for resizable panel persistence#3386
feat: use clientLoader + localStorage for resizable panel persistence#3386devin-ai-integration[bot] wants to merge 4 commits intomainfrom
Conversation
Upgraded packages: - @remix-run/express: 2.1.0 → 2.17.4 - @remix-run/node: 2.1.0 → 2.17.4 - @remix-run/react: 2.1.0 → 2.17.4 - @remix-run/router: 1.15.3 → 1.23.2 - @remix-run/serve: 2.1.0 → 2.17.4 - @remix-run/server-runtime: 2.1.0 → 2.17.4 - @remix-run/dev: 2.1.0 → 2.17.4 - @remix-run/eslint-config: 2.1.0 → 2.17.4 - @remix-run/testing: 2.1.0 → 2.17.4 Also updated tar-fs override for new @remix-run/dev version. Co-Authored-By: Eric Allam <eallam@icloud.com>
Co-Authored-By: Eric Allam <eallam@icloud.com>
Co-Authored-By: Eric Allam <eallam@icloud.com>
Replace server-side cookie reading with client-side localStorage for persisting resizable panel sizes. This eliminates cookie size issues while leveraging Remix 2.4+'s clientLoader feature. Run detail route: Uses clientLoader.hydrate to read localStorage snapshots on initial load, providing panel sizes immediately on client-side navigations. Prompts route: Removes server-side cookie reading and lets react-window-splitter's built-in localStorage support handle persistence (its default autosaveStrategy). Co-Authored-By: Eric Allam <eallam@icloud.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
|
Thanks for your contribution! We require all external PRs to be opened in draft status first so you can address CodeRabbit review comments and ensure CI passes before requesting a review. Please re-open this PR as a draft. See CONTRIBUTING.md for details. |
| outer: undefined as ResizableSnapshot | undefined, | ||
| vertical: undefined as ResizableSnapshot | undefined, | ||
| generations: undefined as ResizableSnapshot | undefined, |
There was a problem hiding this comment.
🟡 Prompts route missing clientLoader — resizable panel snapshots are always undefined
The PR removes the server-side getResizableSnapshot calls from the prompts route and replaces them with undefined, but unlike the runs route (route.tsx:317-327), no clientLoader was added to restore the values from localStorage. This means resizable.outer, resizable.vertical, and resizable.generations will always be undefined on the prompts page, so users' saved panel sizes will never be restored on page load — a regression from the previous behavior where they were read from cookies.
Prompt for agents
The prompts route needs the same clientLoader + localStorage migration that was applied to the runs route. Specifically:
1. Import ClientLoaderFunctionArgs from @remix-run/react (around line 4).
2. Add a getLocalStorageSnapshot helper function (same as the one in the runs route at lines 302-315).
3. Add an exported clientLoader that calls serverLoader, then overrides the resizable field with localStorage lookups for the three keys: "prompt-detail", "prompt-vertical", and "prompt-generations" (these match the autosaveId values used in the ResizablePanelGroup components at lines 582, 589, and 1432).
4. Set clientLoader.hydrate = true as const.
See the runs route file apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx lines 302-327 for the exact pattern to follow.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
This is intentional, not a regression. When the snapshot prop is undefined, the react-window-splitter library reads from localStorage directly — see the library source at PanelGroupImpl lines 300-313:
if (typeof window !== "undefined" && autosaveId && !snapshot && autosaveStrategy === "localStorage") {
const localSnapshot = localStorage.getItem(autosaveId);
if (localSnapshot) { setSnapshot(JSON.parse(localSnapshot)); }
}Since autosaveStrategy defaults to "localStorage" and the component already has autosaveId set, returning undefined from the server lets the library's built-in localStorage persistence handle everything on the client.
A clientLoader was not used here because this route uses typedjson/useTypedLoaderData from remix-typedjson, which has its own serialization layer that doesn't compose cleanly with clientLoader (which routes data through useLoaderData).
| }, | ||
| }; | ||
| } | ||
| clientLoader.hydrate = true as const; |
There was a problem hiding this comment.
🚩 clientLoader.hydrate = true changes initial render behavior
With clientLoader.hydrate = true as const at route.tsx:327, Remix will not render the page until the clientLoader has run, even on initial page load. This means the server-rendered HTML will show a fallback/loading state until the client loader completes (which includes the full serverLoader() fetch). This is a deliberate trade-off: the resizable panels will always have their correct snapshot on first render, but the initial page load may feel slightly slower compared to the previous SSR approach where the cookie-based snapshot was available during server rendering. This is likely acceptable for a dashboard app but worth being aware of.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good observation. One clarification: with clientLoader.hydrate = true, Remix still SSRs the page using the server loader data. On the client, it hydrates with that server data first, then runs clientLoader and re-renders with the localStorage snapshots. So the initial page load isn't blocked — the user sees the server-rendered page immediately, then panels adjust to saved sizes once clientLoader resolves (which is near-instant since localStorage.getItem is synchronous and serverLoader() reuses the already-fetched server data during hydration).
The net effect is: panels flash briefly with default sizes → saved sizes on first SSR load, but on subsequent client-side navigations the saved sizes are available immediately.
Summary
Replaces server-side cookie reading (
getResizableSnapshot) with client-side localStorage for persisting resizable panel sizes. Cookies were hitting the ~4KB size limit; localStorage supports 5-10MB.Two routes modified, two approaches used:
Run detail route (
runs.$runParam/route.tsx): Uses RemixclientLoaderwithhydrate = trueto read panel snapshots from localStorage before first render. AgetLocalStorageSnapshothelper validates the stored JSON shape before returning it.Prompts route (
prompts.$promptSlug/route.tsx): Simply removes server-side cookie reading and returnsundefinedfor snapshots. Thereact-window-splitterlibrary already defaults toautosaveStrategy="localStorage"and reads from it when nosnapshotprop is provided. AclientLoaderwas not used here because this route usesremix-typedjson(typedjson/useTypedLoaderData), which has its own serialization layer that doesn't compose cleanly withclientLoader.In both cases, the library continues to automatically write to localStorage on resize (via
autosaveId), so no save-side changes were needed.This PR is based on the Remix 2.1→2.17.4 upgrade branch (PR #3372), which is required because
clientLoaderwas introduced in Remix 2.4.Review & Testing Checklist for Human
panel-run-parent-v2andpanel-run-treekeys in DevTools → Application → Local Storage)prompt-detail,prompt-vertical,prompt-generations)clientLoader.hydrate = truepattern on the run detail route is the highest-risk areagetResizableSnapshot/resizablePanel.serveris not needed elsewhere for these specific panel IDs — the server module still exists but is no longer imported by these two routesuseTypedLoaderData— theresizablefield now always starts asundefinedfrom the server, souseTypedLoaderDatashould just pass it throughNotes
getLocalStorageSnapshothelper (run detail route, ~line 302) validates stored data by checking for a"status"property. If the library changes its serialization format, this would silently fall back to default sizes rather than crash.remix-typedjsonconstraint, but could be unified later ifremix-typedjsonis removed.Link to Devin session: https://app.devin.ai/sessions/d9fa9953b9bf40e5a8d12b8f5ba5b86b
Requested by: @ericallam